14.1 REST简介
14.1.1 资源就是一切
资源是REST架构方式的核心概念。在博客程序中,用户、文章、评论等都是资源。
每个资源都要使用唯一的URL表示。如某一篇文章URL:/api/posts/1234
。
某一类资源的集合也要有个URL。如文章集合URL:api/posts/
。
API还可以为某一类资源的逻辑子集合定义集合URL。如某一篇文章的所有评论URL:/api/posts/1234/comments/
。
注意:
请求的URL末端没有/
,路由没有/
,则不重定向;
请求的URL末端没有/
,路由有/
,则重定向转向末端带/
的URL。
14.1.2 请求方法
在资源URL上发送请求,使用请求方法表示期望执行的操作。
表14-1 REST架构API中使用的HTTP请求方法
请求方法 |
目标 |
说明 |
状态码 |
GET |
单个资源的URL |
获取目标资源 |
200 |
GET |
资源集合的URL |
获取资源的集合(如果服务器实现了分页,就是一页中的资源) |
200 |
POST |
资源集合的URL |
创建新资源,并将其加入目标集合。服务器为新资源指派URL, 并在响应的Location首部中返回 |
201 |
PUT |
单个资源的URL |
修改一个现有资源。如果客户端能为资源指派URL,还可用来创建新资源 |
200 |
DELETE |
单个资源的URL |
删除一个资源 |
200 |
DELETE |
资源集合的URL |
删除目标集合中的所有资源 |
200 |
14.1.3 请求和响应主体
请求和响应中Content-Type
首部用于指明主体中资源的编码方式。常用的编码方式是JavaScript对象表示法(JSON)和可拓展标记语言(XML)。
14.1.4 版本
Web服务的容错能力要比一般的Web程序大,而且还要保证旧版客户端能继续使用(因为有些客服端如手机客户端,没有进行升级,但也要保证其能正常使用)。处理方法是使用版本区分Web服务所处理的URL。例如首次发布的博客Web服务可以通过/api/v1.0/posts/
提供文章集合。
14.2 使用Flask提供REST Web服务
使用Flask创建REST Web服务很简单,使用route()
修饰器及其methods
可选参数即可。处理JSON数据也同样简单,通过request.json
这个字典获取即可。返回包含JSON的响应只用使用Flask提供的jsonify()
辅助函数从Python字典中生成JSON即可。
14.2.1 创建API蓝本
API蓝本结构如下:
1 2 3 4 5 6 7 8 9 10
| |-flasky |-app/ |-api_1_0 # 可选 |-__init__.py |-users.py |-posts.py |-comments.py |-authentication.py |-errors.py |-decorators.py
|
1. 在app/api/__init__.py
中构造API蓝本:
1 2 3 4 5
| from flask import Blueprint api = Blueprint('api', __name__) from app.api import authentication, posts, users, comments, errors
|
2. 在app/__init__.py
中注册API蓝本:
1 2 3 4 5 6 7
| # ... def create_app(config_name): # ... from app.api import api as api_blueprint app.register_blueprint(api_blueprint, url_prefix='/api/v1.0') # ...
|
14.2.5 资源和JSON的序列化转换
3. 在app/models.py
中定义把文章、用户转换成JSON格式的序列化字典的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| class Post(db.Model): # ... def to_json(self): json_psot = { 'url': url_for('api.get_post', id=self.id), 'body': self.body, 'body_html': self.body_html, 'timestamp': self.timestamp, 'author_url': url_for('api.get_user', id=self.author_id), 'comments_url': url_for('api.get_post_commnets', id=self.id), 'comment_count': self.comments.count() } return json_psot class User(UserMixin, db.Model): # ... def to_json(self): json_user = { 'url': url_for('api.get_user', id=self.id), 'username': self.username, 'member_since': self.member_since, 'last_seen': self.last_seen, 'posts_url': url_for('api.get_user_posts', id=self.id), 'followed_posts_url': url_for('api.get_user_followed_posts', id=self.id), 'post_count': self.posts.count() } return json_user
|
4. 在app/models.py
中定义从JSON格式数据创建博客文章的方法:
1 2 3 4 5 6 7 8 9 10 11
| from app.api.exceptions import ValidationError # ... class Post(db.Model): # ... @staticmethod def from_json(json_post): body = json_post.get('body') if body is None or body == '': raise ValidationError('post does not have a body') # 抛出异常 return Post(body=body)
|
5. 在app/exceptions.py
中定义ValidationError类:
1 2
| class ValidationError(ValueError): pass
|
为了避免在视图函数中编写捕获异常的代码,我们可创建一个全局异常处理程序:
6. 在app/api/errors.py
中定义API中ValidationError异常的处理程序:
1 2 3 4
| # 定义API中ValidationError错误处理程序 @api.errorhandler(ValidationError) def validation_error(e): return bad_request(e.args[0])
|
14.2.6 实现资源端点 & 14.2.7 分页大型资源集合
GET请求往往是最简单的,因为它们只返回信息,无需修改信息。
7. 在app/api/posts.py
中定义分页博客文章资源的GET请求:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| from flask import jsonify, request, g, url_for, current_app from app import db from app.models import Post, Permission from app.api import api from app.api.decorators import permission_required from app.api.errors import forbidden # 获取分页文章集合 @api.route('/posts/') def get_posts(): page = request.args.get('page', 1, type=int) pagination = Post.query.paginate(page, per_page=current_app.config['FLASKY_POSTS_PER_PAGE'], error_out=False) posts = pagination.items prev = None if pagination.has_prev: prev = url_for('api.get_posts', page=page-1, _external=True) next = None if pagination.has_next: next = url_for('api.get_posts', page=page+1, _external=True) return jsonify({ 'posts': [post.to_json() for post in posts], 'perv': prev, 'next': next, 'count': pagination.total }) # 获取某一篇文章 @api.route('/posts/<int:id>') def get_post(id): post = Post.query.get_or_404(id) return jsonify(post.to_json())
|
8. 在app/api/posts.py
中定义博客文章资源的POST请求:
1 2 3 4 5 6 7 8 9 10
| # ... @api.route('/posts/', methods=['POST']) @permission_required(Permission.WRITE_ARTICLES) def new_post(): post = Post.from_json(request.json) post.author = g.current_user db.session.add(post) db.session.commit() return jsonify(post.to_json()), 201, {'Location': url_for('api.get_post', id=post.id)}
|
9. 在app/api/posts.py
中定义博客文章资源的PUT请求:
1 2 3 4 5 6 7 8 9 10 11 12 13
| # ... @api.route('/posts/<int:id>', methods=['PUT']) @permission_required(Permission.WRITE_ARTICLES) def edit_post(id): post = Post.query.get_or_404(id) if g.current_user != post.author and \ not g.current_user.can(Permission.ADMINISTER): return forbidden('Insufficient permissions') post.body = request.json.get('body', post.body) db.session.add(post) db.session.commit() return jsonify(post.to_json())
|
从第6-8步中用到了permission_required
修饰器,下面看看permission_required
修饰器如何定义:
10. 在app/api/decoratiors.py
中定义permission_required修饰器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| from functools import wraps from flask import g from app.api.errors import forbidden def permission_required(permission): def decorator(func): @wraps(func) def decorated_function(*args, **kwargs): if not g.current_user.can(permission): return forbidden('Insufficient permissions') return func(*args, **kwargs) return decorated_function return decorator
|
从第6-9步中都用到了app.api.errors.forbidden
方法,下面看看app/api/errors.py
文件如何定义:
11. 在app/api/errors.py
中定义400、401、403状态码的错误处理程序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| from flask import jsonify from app.exceptions import ValidationError from app.api import api # 无效请求 def bad_request(message): response = jsonify({'error': 'bad request', 'message': message}) response.status_code = 400 return response # 未登录 def unauthorized(message): response = jsonify({'error': 'unauthorizde', 'message': message}) response.status_code = 401 return response # 禁止访问 def forbidden(message): response = jsonify({'error': 'forbidden', 'message': message}) response.status_code = 403 return response
|
14.2.2 错误处理
为统一错误处理程序的响应格式,需要修改app/main/errors.py
,使其内容协商。
为所有客户端生成适当响应的一种方法是,在错误处理程序中,根据客户端请求的格式改写响应,这种技术称为内容协商。
12. 在app/main/errors.py
中使用HTTP内容协商处理错误:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| from flask import render_template, request, jsonify from . import main @main.app_errorhandler(403) def forbidden(e): # 判断请求的首部Accept字段(Werkzeug将其解码为requset.accept_mimetypes)接受哪种响应格式(json或xml) if request.accept_mimetypes.accept_json and \ not request.accept_mimetypes.accept_html: response = jsonify({'error': 'forbidden'}) response.status_code = 403 return response return render_template('403.html'), 403 @main.app_errorhandler(404) def page_not_found(e): if request.accept_mimetypes.accept_json and \ not request.accept_mimetypes.accept_html: response = jsonify({'error': 'not found'}) response.status_code = 404 return response return render_template('404.html'), 404 @main.app_errorhandler(500) def internal_server_error(e): if request.accept_mimetypes.accept_json and \ not request.accept_mimetypes.accept_html: response = jsonify({'error': 'internal server error'}) response.status_code = 500 return response return render_template('500.html'), 500
|
- 该例的错误处理程序会检查Accept请求首部(Werkzeug将其解码为requset.accept_mimetypes),根据首部的值决定客户端期望接受的响应格式(JSON或XML)。浏览器一般不限制响应的格式,所以只为只接受JSON而不接受HTML格式的客户端生成JSON格式响应。
表14-2 API返回的常见HTTP状态码
状态码 |
名称 |
说明 |
200 |
OK(成功) |
请求成功完成 |
201 |
Created(已创建) |
请求成功完成并创建了一个新资源 |
400 |
Bad request(坏请求) |
请求不可用或不一致 |
401 |
Unauthorized(未授权) |
请求未包含认证信息 |
403 |
Forbidden(禁止) |
请求中发送的认证密令无权访问目标 |
404 |
Notfound(未找到) |
URL对应的资源不存在 |
405 |
Method not allowed(不允许使用的方法 |
指定资源不支持请求使用的方法 |
500 |
Internal server error(内部服务器错误) |
处理请求的过程中发生意外错误 |
14.2.3 使用Flask-HTTPAuth认证用户 & 14.2.4 基于令牌的认证
和普通Web程序一样,Web服务也需要保护信息,确认未经授权的用户无法访问。为此,RIA必须询问用户的登录密令,并将其传给服务器验证。
REST Web服务的特征之一就是无状态,即服务器在两次请求之间不能“记住”客户端的任何信息,客户端发出的请求必须包含所有信息,因此所有请求都必须包含用户密令。
默认情况下,Flask会把会话保存在客户端的cookie中,因此服务器没有保存任何用户相关信息,都转交给客户端保存了。这种实现方式看起来遵守REST架构的无状态要求,但在REST Web服务中使用cookie有点不现实,因为Web浏览器之外的客户端很难提供对cookie的支持。
又因为REST架构基于HTTP协议,所以发送密令的最佳方式是使用HTTP认证,基本认证和摘要认证都可以。在HTTP认证中,用户密令包含在请求的Authorization首部中。
每次请求时,客户端都要发送认证密令,为了避免总是发送敏感信息(认证密令),我们可以提供一种基于令牌的认证方案:客户端(1)先把登录密令发送给一个特殊的URL,从而生成认证令牌;(2)客户端获得令牌后,就可以用令牌代替密令认证请求。
13. 在app/api/authentication.py
中初始化Flask-HTTPAuth,并支持令牌验证回调:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| from flask import g, jsonify from flask_httpauth import HTTPBasicAuth from app.models import User from app.api import api from app.api.errors import unauthorized, forbidden auth = HTTPBasicAuth() @auth.verify_password def verify_password(email_or_token, password): # (1) if email_or_token == '': return False # (2) if password == '': g.current_user = User.verify_auth_token(email_or_token) g.token_used = True return g.current_user is not None # (3) user = User.query.filter_by(email=email_or_token).first() if not user: return False g.current_user = user g.token_used = False return user.verify_password(password) # 调用User模型中的verify_password方法
|
- Flask-HTTPAuth提供了一个便利的包(
HTTPBasicAuth().verify_password
),可以把协议的细节隐藏在修饰器中,类似于Flask-Login提供的login_required
修饰器。
- 由于这种用户认证方法只在API蓝本中使用,所以Flask-HTTPAuth只在蓝本包中初始化,而不像其他扩展那样在程序包中初始化。
- 验证回调函数把通过认证的用户保存在Flask的全局对象
g
中,如此一来,视图函数就能进行访问。
- 该例中,第一个参数是电子邮件或认证令牌。(1)如果这个参数为空,则返回False;(2)如果
password
参数为空,就假定email_or_token
参数是认证令牌,按照令牌的方式进行认证;(3)如果两个参数都不为空,就假定使用了电子邮件和密码进行认证。
- 为了让视图函数能区分这两种认证方法(电子邮件+密码、认证令牌),我们添加了
g.token_used
变量。
14. 在app/api/authentication.py
中定义生成认证令牌的路由:
1 2 3 4 5 6 7 8 9
| # ... # 定义用于获取密令的路由 @api.route('/tokens/', methods=['POST']) def get_token(): if g.current_user.is_anonymous or g.token_used: return unauthorized('Invalid credentials') return jsonify({'token': g.current_user.generate_auth_token(expiration=3600), 'expiration': 3600})
|
15. 在app/api/authentication.py
中定义Flask-HTTPAuth错误处理程序:
1 2 3 4 5
| # ... @auth.error_handler def auth_error(): return unauthorized('Invalid credentials')
|
16. 在app/api/authentication.py
中使得API蓝本所有路由在每次请求前都进行认证:
1 2 3 4 5 6 7
| # ... @api.before_request @auth.login_required def before_request(): if not g.current_user.is_anonymous and \ not g.current_user.confirmed: # 判断该用户是否已经确认账户 return forbidden('Unconfirmed account')
|
在第13步中使用到了User.verify_auth_token
方法和第14步中使用到了User.generate_auth_token
方法,其定义如下:
17. 在app/models.py
中定义基于令牌的认证:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class User(UserMixin, db.Model): # ... def generate_auth_token(self, exporation): s = Serializer(current_app.confir['SECRET_KEY'], expires_in=exporation) return s.dumps({'id': self.id}).decode('utf-8') @staticmethod def verify_auth_token(token): s = Serializer(current_app.config['SECRET_KEY']) try: data = s.loads(token) except: return None return User.query.get(data['id'])
|
14.2.8 使用HTTPie测试Web服务
1. 安装HTTPie:
1
| (venv) $ pip install httpie
|
2. GET请求可按照如下方式发起:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| (venv) $ http --json --auth <email>:<password> GET http://127.0.0.1:5000/api/v1.0/posts HTTP/1.0 200 OK Content-Length: 7018 Content-Type: application/json Date: Sun, 22 Dec 2013 08:11:24 GMT Server: Werkzeug/0.9.4 python/3.6 { "posts": [ ... ], "prev": null, "next": "http://127.0.0.1:5000/api/v1.0/posts/?page=2", "count": 150 }
|
如果API允许匿名用户访问的话,可以这个发起请求:
1
| (venv) $ http --json --auth : GET http://127.0.0.1:5000/api/v1.0/posts/
|
3. 发送POST请求创建一篇新博客文章:
1 2 3 4
| (venv) $ http --auth <email>:<password> --json POST http://127.0.0.1:5000/api/v1.0/posts/ "body=I'm adding a post from the *command line*." HTTP/1.0 201 CREATED Content-Length :360 # ...
|
4. 要想使用认证令牌,可向/api/v1.0/token
发送请求:
1 2 3 4 5 6 7 8 9 10 11
| (venv) $ http --auth <email>:<password> --json GET http://127.0.0.1:5000/api/v1.0/token HTTP/1.0 200 OK Content-Lenght: 162 Content-Type: application/json Date: Sat, 04 Jan 2017 08:38:47 GMT Server: Werkzeug/0.9.4 Python/3.3.3 { "expiration": 3600, "token": "eqJpYXQiOjex..............iSFMy..." }
|
5. 在接下来的1小时内,可以用这个令牌访问API,请求时要和空密码一起发送:
1
| (venv) $ http --json --auth eyJpYXQi.....ISFMy...: GET http://127.0.0.1:5000/api/v1.0/posts/
|
令牌过期后,请求会返回401错误,表示需要重新获取令牌。